Racket 词法闭包入门指南

#Technomous #Lisp #Racket

在学习函数式编程(尤其是 Racket、Scheme、JavaScript 等语言)时,你一定会遇到一个关键概念——闭包(Closure)。这篇文章将带你从零理解什么是闭包、为什么它重要,以及它如何让函数拥有类似“对象”的能力。

什么是词法闭包?

在计算机科学中,闭包(Closure),又称词法闭包(Lexical Closure)函数闭包(Function Closure),指的是:

一个引用了外部自由变量的函数,并且这些被引用的变量会与函数共存,即使函数已经离开了它原本的创建环境。

换句话说:

这种“函数 + 环境变量一并打包”的机制,就是闭包。

为什么自由变量通常无法再访问?

来看一个简单事实:

一个变量离开了它的作用域,就无法继续访问。

正常情况下,离开一个 let、一个函数体、一个 block 的变量都会被销毁。但闭包改变了这一点 —— 它让某些变量 在原本作用域结束后仍能存活

这种能力非常关键,是函数式语言能模拟“类”和“对象”的核心原因。

闭包是如何扩展变量作用域的?

关键机制在于 匿名函数捕获外部变量

来看一个简单示例(Racket 风格伪码):

(let ([count 0])
  (lambda ()
    (set! count (+ count 1))
    count))

这里的匿名函数引用了外部的 count,于是:

闭包因此扩展了变量的生命周期与作用域。

闭包 = 函数 + 状态(对象的原型雏形)

当我们把一个函数连同内部变量一起“打包”后,这份包就和 OOP(面向对象)中的“对象实例”非常像:

例如,一个“计数器工厂”:

(define (make-counter)
  (let ([count 0])
    (lambda (msg . args)
      (cond
        [(eq? msg 'inc)
         (set! count (add1 count))
         count]
        [(eq? msg 'get)
         count]
        [else
         (error "Unknown message" msg)]))))

创建两个实例:

(define c1 (make-counter))
(define c2 (make-counter))

(send c1 'inc) ; => 1
(send c1 'inc) ; => 2

(send c2 'get) ; => 0
(send c2 'inc) ; => 1

其中我们约定用 (send obj 'method ...) 来发送消息(见下面的 send 帮助函数)。

不同闭包实例拥有不同的 count。这就是“对象的行为”,但完全无需类(class)语法。

闭包为何重要?

闭包是函数式编程中最强大的概念之一,它的作用包括:

从简单的计数器,到模块系统、回调、状态机,都离不开闭包。

用闭包实现一个迷你类系统(Racket 示例)

下面我们展示如何用闭包做一个极简却实用的“类系统”——用来说明思想而不是替代语言自带的类/对象系统。这个迷你系统包含:

所有示例均为纯 Racket,可直接在 DrRacket 或 racket REPL 中运行。

send 辅助函数

;; 发送消息给对象(对象是一个 proc:(obj msg . args))
(define (send obj msg . args)
  (apply obj msg args))

例 1:计数器类(最小化实现)

#lang racket

(define (make-counter)
  (let ([count 0])
    ;; dispatcher
    (lambda (msg . args)
      (cond
        [(eq? msg 'inc)
         (set! count (add1 count))
         count]
        [(eq? msg 'get)
         count]
        [(eq? msg 'reset)
         (set! count 0)
         count]
        [else
         (error "Unknown method" msg)]))))

使用:

(define c (make-counter))
(send c 'get) ; => 0
(send c 'inc) ; => 1
(send c 'inc) ; => 2
(send c 'reset) ; => 0

说明:count 是私有的,外界只能通过 'inc'get'reset 操作它。

例 2:带私有字段和方法的 Person

(define (make-person name age)
  (let ([name (string->symbol name)] ; 保持为 symbol 只是示例
        [age age])
    (letrec ([greet
              (lambda ()
                (format "Hi, I'm ~a and I'm ~a years old." (symbol->string name) age))]

             [self
              (lambda (msg . args)
                (cond
                  [(eq? msg 'get-name) (symbol->string name)]
                  [(eq? msg 'set-name) (set! name (string->symbol (car args))) 'ok]
                  [(eq? msg 'get-age) age]
                  [(eq? msg 'have-birthday) (set! age (add1 age)) age]
                  [(eq? msg 'greet) (greet)]
                  [else (error "Unknown method" msg)]))])
      self)))

使用:

(define p (make-person "Alice" 30))
(send p 'greet)      ; => "Hi, I'm Alice and I'm 30 years old."
(send p 'have-birthday) ; => 31
(send p 'get-age)    ; => 31
(send p 'set-name "Alicia") ; => 'ok
(send p 'get-name)   ; => "Alicia"

说明:nameage 完全私有;greet 是一个闭包内部的辅助函数,也能访问私有字段。

例 3:通过委托实现“继承”或扩展(Student 基于 Person)

我们用委托(delegation) 的方式扩展对象行为:构建一个 Student,它内部创建了一个 Person 对象用于处理共同的方法;Student 自己处理学生相关的方法,未处理的消息则委托给 Person

(define (make-student name age school)
  (let ([person (make-person name age)]
        [school school])
    (letrec ([self
              (lambda (msg . args)
                (cond
                  ;; Student-specific methods
                  [(eq? msg 'get-school) school]
                  [(eq? msg 'set-school) (set! school (car args)) 'ok]
                  [(eq? msg 'study) (format "~a studies at ~a" (send person 'get-name) school)]
                  ;; Delegate other methods to person
                  [else (apply person msg args)]))])
      self)))

使用:

(define s (make-student "Bob" 20 "Racket Univ"))
(send s 'greet)       ; delegate -> "Hi, I'm Bob and I'm 20 years old."
(send s 'get-school)  ; => "Racket Univ"
(send s 'study)       ; => "Bob studies at Racket Univ"

说明:make-student 内部创建了一个 Person 对象并重用其方法,这是一种“组合 + 委托”的继承风格——也常见于原型面向对象(prototype-based OOP)。

例 4:轻量方法查找表(更结构化的实现)

上面每次都用 cond 处理消息;在方法多的时候,可以用查表的方式简化,实现一个小工具 make-object 来把方法表和闭包状态绑在一起:

(define (make-object method-table)
  ;; method-table 是一个 alist: '((msg . proc) ...)
  (lambda (msg . args)
    (let ([pair (assoc msg method-table)])
      (if pair
          (apply (cdr pair) args) ; 方法本身应当通过闭包捕获私有字段
          (error "Unknown method" msg)))))

示例:用它重写一个计数器(方法利用外部闭包字段):

(define (make-counter-2)
  (let ([count 0])
    (make-object
     (list
      (cons 'inc (lambda ()
                   (set! count (add1 count))
                   count))
      (cons 'get (lambda ()
                   count))))))

使用方式仍是 (send obj 'inc)。这种模式把方法查找和方法实现分离,方法仍然可以访问 count(因为它们是在 let 内定义、并由 make-object 使用)。

为什么这是“迷你类系统”?

这套模式用闭包就能实现面向对象的大部分基本特性,适合用来理解面向对象的语义,或在不想引入类语法时做轻量对象抽象。